@ampless/runtime 0.2.0-alpha.10 → 0.2.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -159,32 +159,42 @@ declare function renderBody(post: Post): string;
159
159
  * Convert a tiptap doc to its HTML form. Same renderer the public
160
160
  * site uses. Defensive: tiptap accepts an HTML string as initial
161
161
  * content and parses it on mount, but won't fire onUpdate until the
162
- * user edits so a format-switch chain (e.g. markdown tiptap
162
+ * user edits, so a format-switch chain (e.g. markdown -> tiptap ->
163
163
  * markdown without editing) can still hand us a raw HTML string
164
164
  * here. In that case, return it as-is rather than walking it as a
165
165
  * malformed tiptap node and producing empty output.
166
166
  */
167
167
  declare function tiptapToHtml(doc: unknown): string;
168
- /** Convert markdown to HTML using the built-in minimal renderer. */
168
+ /** Convert markdown to HTML using marked + GFM. */
169
169
  declare function markdownToHtml(md: string): string;
170
170
  /**
171
171
  * Walk a tiptap doc and emit Markdown. Mirrors `renderTiptap` in
172
172
  * shape but produces markdown syntax. Loses anything markdown can't
173
173
  * express (data attributes, image display modes, custom marks).
174
174
  *
175
+ * Notes on info loss:
176
+ * - underline / highlight are not in GFM, so they fall back to the
177
+ * literal `<u>` / `<mark>` HTML tags (preserved as-is across round trips).
178
+ * - paragraph / heading textAlign cannot be expressed in markdown and
179
+ * is therefore lost on conversion.
180
+ *
175
181
  * Same defensive path as tiptapToHtml: a string input means tiptap
176
182
  * hasn't emitted JSON yet (the body is still the HTML we handed it).
177
183
  * Route through htmlToMarkdown so the content survives.
178
184
  */
179
185
  declare function tiptapToMarkdown(doc: unknown): string;
180
186
  /**
181
- * Regex-based HTML Markdown converter. Handles the tag set the
187
+ * Regex-based HTML -> Markdown converter. Handles the tag set the
182
188
  * editor produces (`<p>` `<h1>`-`<h6>` `<strong>` `<em>` `<a>`
183
189
  * `<img>` `<ul>` `<ol>` `<li>` `<code>` `<pre>` `<blockquote>` `<hr>`
184
- * `<br>`). Anything else (tables, sections, divs) keeps its content
185
- * but loses structural meaning.
190
+ * `<br>` `<u>` `<mark>` `<table>` task-list `<ul data-type="taskList">`).
191
+ * Decorative containers like `<div style="text-align:...">` are dropped.
192
+ *
193
+ * Tables are reduced to GFM pipe syntax via convertHtmlTable. Complex
194
+ * nested content inside cells (lists, other tables) is flattened to
195
+ * plain text.
186
196
  *
187
- * Not a full library there are known limits like nested formatting
197
+ * Not a full library, there are known limits like nested formatting
188
198
  * inside list items potentially merging. Acceptable for a v0.x
189
199
  * format-switch convenience; complex HTML round-trips shouldn't be
190
200
  * relied on.
package/dist/index.js CHANGED
@@ -285,9 +285,17 @@ ${lines.join("\n")}
285
285
  }
286
286
 
287
287
  // src/rendering.ts
288
+ import { marked } from "marked";
288
289
  function escape(s) {
289
290
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
290
291
  }
292
+ function textAlignStyle(attrs) {
293
+ const v = attrs?.textAlign;
294
+ if (v === "left" || v === "center" || v === "right" || v === "justify") {
295
+ return ` style="text-align: ${v}"`;
296
+ }
297
+ return "";
298
+ }
291
299
  function renderTiptap(node) {
292
300
  if (node.type === "text") {
293
301
  let html = escape(node.text ?? "");
@@ -296,6 +304,8 @@ function renderTiptap(node) {
296
304
  else if (mark.type === "italic") html = `<em>${html}</em>`;
297
305
  else if (mark.type === "code") html = `<code>${html}</code>`;
298
306
  else if (mark.type === "strike") html = `<s>${html}</s>`;
307
+ else if (mark.type === "underline") html = `<u>${html}</u>`;
308
+ else if (mark.type === "highlight") html = `<mark>${html}</mark>`;
299
309
  else if (mark.type === "link") {
300
310
  const href = escape(String(mark.attrs?.href ?? "#"));
301
311
  html = `<a href="${href}" target="_blank" rel="noopener">${html}</a>`;
@@ -308,10 +318,10 @@ function renderTiptap(node) {
308
318
  case "doc":
309
319
  return children;
310
320
  case "paragraph":
311
- return `<p>${children}</p>`;
321
+ return `<p${textAlignStyle(node.attrs)}>${children}</p>`;
312
322
  case "heading": {
313
323
  const level = Number(node.attrs?.level ?? 1);
314
- return `<h${level}>${children}</h${level}>`;
324
+ return `<h${level}${textAlignStyle(node.attrs)}>${children}</h${level}>`;
315
325
  }
316
326
  case "bulletList":
317
327
  return `<ul>${children}</ul>`;
@@ -336,52 +346,39 @@ function renderTiptap(node) {
336
346
  const display = node.attrs?.display ? ` data-display="${escape(String(node.attrs.display))}"` : "";
337
347
  return `<img src="${src}" alt="${alt}"${title}${display} loading="lazy" />`;
338
348
  }
349
+ case "table":
350
+ return `<table class="tiptap-table"><tbody>${children}</tbody></table>`;
351
+ case "tableRow":
352
+ return `<tr>${children}</tr>`;
353
+ case "tableHeader":
354
+ return `<th${tableCellAttrs(node.attrs)}>${children}</th>`;
355
+ case "tableCell":
356
+ return `<td${tableCellAttrs(node.attrs)}>${children}</td>`;
357
+ case "taskList":
358
+ return `<ul data-type="taskList">${children}</ul>`;
359
+ case "taskItem": {
360
+ const checked = node.attrs?.checked === true ? "true" : "false";
361
+ return `<li data-type="taskItem" data-checked="${checked}">${children}</li>`;
362
+ }
339
363
  default:
340
364
  return children;
341
365
  }
342
366
  }
343
- function renderMarkdown(md) {
344
- const lines = md.split("\n");
345
- const out = [];
346
- let i = 0;
347
- while (i < lines.length) {
348
- const line = lines[i] ?? "";
349
- if (line.startsWith("# ")) {
350
- out.push(`<h1>${escape(line.slice(2))}</h1>`);
351
- i++;
352
- } else if (line.startsWith("## ")) {
353
- out.push(`<h2>${escape(line.slice(3))}</h2>`);
354
- i++;
355
- } else if (line.startsWith("```")) {
356
- const lang = line.slice(3).trim();
357
- const code = [];
358
- i++;
359
- while (i < lines.length && !(lines[i] ?? "").startsWith("```")) {
360
- code.push(lines[i] ?? "");
361
- i++;
362
- }
363
- i++;
364
- out.push(
365
- `<pre><code${lang ? ` class="language-${escape(lang)}"` : ""}>${escape(code.join("\n"))}</code></pre>`
366
- );
367
- } else if (line.startsWith("- ")) {
368
- const items = [];
369
- while (i < lines.length && (lines[i] ?? "").startsWith("- ")) {
370
- items.push(`<li>${renderInlineMarkdown((lines[i] ?? "").slice(2))}</li>`);
371
- i++;
372
- }
373
- out.push(`<ul>${items.join("")}</ul>`);
374
- } else if (line.trim() === "") {
375
- i++;
376
- } else {
377
- out.push(`<p>${renderInlineMarkdown(line)}</p>`);
378
- i++;
379
- }
367
+ function tableCellAttrs(attrs) {
368
+ let out = "";
369
+ const colspan = Number(attrs?.colspan ?? 1);
370
+ if (colspan > 1) out += ` colspan="${colspan}"`;
371
+ const rowspan = Number(attrs?.rowspan ?? 1);
372
+ if (rowspan > 1) out += ` rowspan="${rowspan}"`;
373
+ const colwidth = attrs?.colwidth;
374
+ if (Array.isArray(colwidth) && colwidth.length > 0) {
375
+ const w = Number(colwidth[0]);
376
+ if (Number.isFinite(w) && w > 0) out += ` style="width: ${w}px"`;
380
377
  }
381
- return out.join("\n");
378
+ return out;
382
379
  }
383
- function renderInlineMarkdown(text) {
384
- return text.replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
380
+ function renderMarkdown(md) {
381
+ return marked.parse(md, { gfm: true, breaks: false, async: false });
385
382
  }
386
383
  function renderBody(post) {
387
384
  if (post.format === "html") return String(post.body);
@@ -412,6 +409,8 @@ function tiptapNodeToMarkdown(node) {
412
409
  else if (mark.type === "italic") txt = `*${txt}*`;
413
410
  else if (mark.type === "code") txt = `\`${txt}\``;
414
411
  else if (mark.type === "strike") txt = `~~${txt}~~`;
412
+ else if (mark.type === "underline") txt = `<u>${txt}</u>`;
413
+ else if (mark.type === "highlight") txt = `<mark>${txt}</mark>`;
415
414
  else if (mark.type === "link") txt = `[${txt}](${String(mark.attrs?.href ?? "#")})`;
416
415
  }
417
416
  return txt;
@@ -449,12 +448,54 @@ function tiptapNodeToMarkdown(node) {
449
448
  const alt = String(node.attrs?.alt ?? "");
450
449
  return `![${alt}](${src})`;
451
450
  }
451
+ case "table":
452
+ return tiptapTableToMarkdown(node);
453
+ case "taskList":
454
+ return children + "\n";
455
+ case "taskItem": {
456
+ const checked = node.attrs?.checked === true ? "x" : " ";
457
+ const inner = children.replace(/\n+$/, "");
458
+ const [first, ...rest] = inner.split("\n");
459
+ const cont = rest.map((l) => l ? " " + l : l).join("\n");
460
+ return `- [${checked}] ${first ?? ""}${cont ? "\n" + cont : ""}
461
+ `;
462
+ }
452
463
  default:
453
464
  return children;
454
465
  }
455
466
  }
467
+ function tiptapTableToMarkdown(node) {
468
+ const rows = node.content ?? [];
469
+ if (rows.length === 0) return "";
470
+ const renderedRows = [];
471
+ let headerIdx = -1;
472
+ for (let i = 0; i < rows.length; i++) {
473
+ const row = rows[i];
474
+ const cells = row.content ?? [];
475
+ const cellTexts = cells.map((c) => {
476
+ const inner = (c.content ?? []).map(tiptapNodeToMarkdown).join("");
477
+ return inner.replace(/\n+$/, "").replace(/\n/g, "<br>").replace(/\|/g, "\\|");
478
+ });
479
+ renderedRows.push(cellTexts);
480
+ if (headerIdx === -1 && cells.some((c) => c.type === "tableHeader")) headerIdx = i;
481
+ }
482
+ if (headerIdx === -1) headerIdx = 0;
483
+ const header = renderedRows[headerIdx] ?? [];
484
+ const body = renderedRows.filter((_, i) => i !== headerIdx);
485
+ const cols = header.length;
486
+ const headerLine = "| " + header.join(" | ") + " |";
487
+ const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
488
+ const bodyLines = body.map((r) => {
489
+ const cells = Array.from({ length: cols }, (_, i) => r[i] ?? "");
490
+ return "| " + cells.join(" | ") + " |";
491
+ });
492
+ return "\n" + [headerLine, sepLine, ...bodyLines].join("\n") + "\n\n";
493
+ }
456
494
  function htmlToMarkdown(html) {
457
495
  let md = html;
496
+ md = md.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (_, inner) => {
497
+ return "\n" + convertHtmlTable(String(inner)) + "\n";
498
+ });
458
499
  md = md.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => {
459
500
  return "\n" + "#".repeat(Number(level)) + " " + String(text).trim() + "\n\n";
460
501
  });
@@ -467,6 +508,9 @@ function htmlToMarkdown(html) {
467
508
  md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content) => {
468
509
  return "\n" + String(content).trim().split("\n").map((l) => "> " + l).join("\n") + "\n\n";
469
510
  });
511
+ md = md.replace(/<ul[^>]*data-type="taskList"[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
512
+ return "\n" + convertHtmlTaskList(String(items)) + "\n";
513
+ });
470
514
  md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, items) => {
471
515
  return "\n" + String(items).replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n") + "\n";
472
516
  });
@@ -486,11 +530,63 @@ function htmlToMarkdown(html) {
486
530
  md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*");
487
531
  md = md.replace(/<s>([\s\S]*?)<\/s>/gi, "~~$1~~");
488
532
  md = md.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`");
533
+ const PH_U_OPEN = "AMP_U_OPEN";
534
+ const PH_U_CLOSE = "AMP_U_CLOSE";
535
+ const PH_MARK_OPEN = "AMP_MARK_OPEN";
536
+ const PH_MARK_CLOSE = "AMP_MARK_CLOSE";
537
+ md = md.replace(/<u>([\s\S]*?)<\/u>/gi, `${PH_U_OPEN}$1${PH_U_CLOSE}`);
538
+ md = md.replace(/<mark>([\s\S]*?)<\/mark>/gi, `${PH_MARK_OPEN}$1${PH_MARK_CLOSE}`);
489
539
  md = md.replace(/<\/?[^>]+>/g, "");
540
+ md = md.split(PH_U_OPEN).join("<u>").split(PH_U_CLOSE).join("</u>").split(PH_MARK_OPEN).join("<mark>").split(PH_MARK_CLOSE).join("</mark>");
490
541
  md = md.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
491
542
  md = md.replace(/\n{3,}/g, "\n\n");
492
543
  return md.trim() + "\n";
493
544
  }
545
+ function convertHtmlTable(inner) {
546
+ const stripped = inner.replace(/<\/?(thead|tbody)[^>]*>/gi, "");
547
+ const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
548
+ const rows = [];
549
+ let m;
550
+ while ((m = rowRe.exec(stripped)) !== null) {
551
+ const rowHtml = m[1] ?? "";
552
+ const cellRe = /<(th|td)[^>]*>([\s\S]*?)<\/\1>/gi;
553
+ const cells = [];
554
+ let isHeader = false;
555
+ let cm;
556
+ while ((cm = cellRe.exec(rowHtml)) !== null) {
557
+ if ((cm[1] ?? "").toLowerCase() === "th") isHeader = true;
558
+ cells.push(normalizeTableCell(cm[2] ?? ""));
559
+ }
560
+ if (cells.length > 0) rows.push({ isHeader, cells });
561
+ }
562
+ if (rows.length === 0) return "";
563
+ let headerIdx = rows.findIndex((r) => r.isHeader);
564
+ if (headerIdx === -1) headerIdx = 0;
565
+ const header = rows[headerIdx].cells;
566
+ const body = rows.filter((_, i) => i !== headerIdx).map((r) => r.cells);
567
+ const cols = header.length;
568
+ const headerLine = "| " + header.join(" | ") + " |";
569
+ const sepLine = "| " + Array.from({ length: cols }, () => "---").join(" | ") + " |";
570
+ const bodyLines = body.map((cells) => {
571
+ const padded = Array.from({ length: cols }, (_, i) => cells[i] ?? "");
572
+ return "| " + padded.join(" | ") + " |";
573
+ });
574
+ return [headerLine, sepLine, ...bodyLines].join("\n") + "\n";
575
+ }
576
+ function normalizeTableCell(html) {
577
+ return html.replace(/<br\s*\/?>/gi, " ").replace(/<\/?[^>]+>/g, "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
578
+ }
579
+ function convertHtmlTaskList(items) {
580
+ const liRe = /<li[^>]*data-checked="(true|false)"[^>]*>([\s\S]*?)<\/li>/gi;
581
+ const out = [];
582
+ let m;
583
+ while ((m = liRe.exec(items)) !== null) {
584
+ const checked = m[1] === "true" ? "x" : " ";
585
+ const inner = String(m[2] ?? "").replace(/<\/?p[^>]*>/gi, "").trim();
586
+ out.push(`- [${checked}] ${inner}`);
587
+ }
588
+ return out.join("\n");
589
+ }
494
590
 
495
591
  // src/index.ts
496
592
  function createAmpless(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "0.2.0-alpha.10",
3
+ "version": "0.2.0-alpha.12",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,9 +47,10 @@
47
47
  "class-variance-authority": "^0.7.1",
48
48
  "clsx": "^2.1.1",
49
49
  "lucide-react": "^1.16.0",
50
+ "marked": "^14.1.4",
50
51
  "tailwind-merge": "^3.6.0",
51
- "ampless": "0.2.0-alpha.7",
52
- "@ampless/plugin-og-image": "0.2.0-alpha.7"
52
+ "ampless": "0.2.0-alpha.8",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.8"
53
54
  },
54
55
  "peerDependencies": {
55
56
  "next": "^15 || ^16",